<?php
require( 'jsmin.class.php' );
/*
 * Copyright (c) 2009-2010 Ben Blanks <ben.a.blanks@gmail.com>
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */
/**
 * This is a packager that allows multiple files to be packaged
 * into a single file for the purpose of compiling many files into a single
 * source file. It also has the ability to minify the source code as well.
 *
 * This class supports:
 * <ul>
 *   <li>304 headers based on the compiled file</li>
 *   <li>minification of the compiled file from multiple source files</li>
 *   <li>gzipping the send file data when a status 200 header is required</li>
 * </ul>
 *
 * @package BB.Toolkit.Packager
 * @author Ben.Blanks
 * @version 1.5
 * @license http://opensource.org/licenses/mit-license.php MIT License
 * @link http://code.google.com/p/bb-toolkit/source/browse/trunk/Packager/
 */
class Packager {
  /**
   * A bypass for debugging purposes. SHOULD BE OFF AT ALL TIMES (except when bugfixing in this class)
   * @var boolean
   * @since 1.0
   */
  private $bDebug = false;
  /**
   * Minify the compiled code.
   * @var boolean
   * @since 1.0
   */
  private $bMinify = true;
  /**
   * Gzip the 200 request to the browser
   * @var boolean
   * @since 1.0
   */
  private $bGzip   = true;
  /**
   * Type of processing (js/css)
   * @var string
   * @since 1.0
   */
  private $sType;
  /**
   * The directory to save the final compiled files
   * @var string
   * @since 1.0
   */
  private $sSaveDir;
  /**
   * The filename to save the final compiled file to.
   * @var string
   * @since 1.0
   */
  private $sSaveFile;
  /**
   * The source directory of the files that make up the compiled file.
   * @var string
   * @since 1.0
   */
  private $sSrcDir;
  /**
   * The files we do not wish to check for updates.
   * @var array
   * @since 1.5
   */
  private $aFileLocks = array();
  /**
   * The files to compile into a single file.
   * @var array
   * @since 1.0
   */
  private $aFiles = array();

  /**
   * Sets the settings which are required for this script to function properly.
   *
   * @param string $sType      The type of procesing to perform "js" / "css"
   * @param string $sSaveDir   The directory to be used for the compiled file to be published to.
   * @param string $sSrcDir    The location of the source files (the uncompiled files)
   * @param boolean $bMinify   Minify the compiled code?
   * @param boolean $bGzip     Send the first request to the client using a compressed connection.
   * @since 1.0
   */
  function __construct( $aArgs = array() ) {
    // $sType , $sSaveDir , $sSrcDir , $bMinify = true , $bGzip = true ) {
    $aDefaults = array(
	    'type'        => 'js',
	    'cache_dir'   => 'cache/',
	    'src_dir'     => 'src/',
	    'build_dir'   => 'build/',
	    'minify'      => true,
	    'gzip'        => true,
    );
    $aArgs = array_merge( $aDefaults , $aArgs );
     
    // Capture any unwanted content;
    ob_start();

    $this->sType      = $aArgs['type'];
    $this->sCacheDir  = $aArgs['cache_dir'];
    $this->sSrcDir    = $aArgs['src_dir'];
    $this->sBuildDir  = $aArgs['build_dir'];
    $this->bMinify    = $aArgs['minify'];
    $this->bGzip      = $aArgs['gzip'];
  }

  /**
   * Processes the files which are set using AddFile and then runs through the options set to it.
   *
   * 1.5 - Updated to include the ability to skip over certain files so they are
   *       essentially version locked.
   *
   * @param string  $sSaveFile  The compiled filename to be processed.
   * @param boolean $bRender    Render the file after the processing (default: true)
   * @since 1.0
   */
  public function Process( $sSaveFile , $sAction = 'render' ) {
    $bResult = false;
    if( array_key_exists( $sSaveFile , $this->aFiles ) ) {
      $bResult = true;
    }
    
    $aChangedFiles = array();
    $this->iLatestTimestamp = 0;
    // Get the list of changed files
    if( $bResult ) {
      $sSettings = ($this->bGzip) ? 'gzip.':'no_gzip.';
      $sSettings .= ($this->bMinify) ? 'minify':'no_minify';
      $sSerialized = md5( serialize( $this->aFiles[$sSaveFile] ) );
      $this->sSaveFile = $this->sCacheDir.$sSettings.'-'.$sSerialized.'-'.$sSaveFile;
      $this->sBuildFile = $this->sBuildDir.$sSaveFile;
      
      $bSaveFileExists = file_exists( $this->sSaveFile );
      $iSaveFileModified = ( $bSaveFileExists ) ? filemtime( $this->sSaveFile ) : 0;
      
      foreach( $this->aFiles[$sSaveFile] as $sFilePath ) {
        if(
          !in_array( $sFilePath , $this->aFileLocks ) &&
          file_exists( $this->sSrcDir.$sFilePath )
        ) {
          $iFileModDate = filemtime( $this->sSrcDir.$sFilePath );
          if( $iSaveFileModified < $iFileModDate ) {
            $aChangedFiles[] = $sFilePath;
          }
          if( $this->iLatestTimestamp < $iFileModDate ) {
            $this->iLatestTimestamp = $iFileModDate;
          }
        }
      }
      	
      // If the source files and the compiled files latest timestamp are the same
      // then we simply process the render and move on.
      if(
        !$this->bDebug &&
        $iSaveFileModified > 0 &&
        count( $aChangedFiles ) === 0
      ) {
        $bResult = false;
        if( $sAction == 'render' ) {
          $this->Render();
        }
      }
    }

    /**
     * TODO: Add the ability to load only the files that need loading
     * and replace the content between the {filepath} {/filepath} comments
     * if the comment doesnt exist add it to the end of the file.
     */
    // Complete the processing phase.
    if( $bResult ) {
      $bPartialCache = true;
      if( !$bSaveFileExists ) {
        $bPartialCache = false;
      }
      $sFileContent = '';
      // We only get in here if the file exists and there are 
      // modifications to certain files.
      if( $bPartialCache && count( $aChangedFiles ) > 0 ) {
        $sFileContent = file_get_contents( $this->sSaveFile );
        foreach( $aChangedFiles as $sFilePath ){
          $sRegex = "/\/\*\*{$sFilePath}\*\*\/(.*)\/\*\*\/{$sFilePath}\*\*\/\n/";
          if( preg_match( $sRegex , $sFileContent ) ) {
            $sContent = file_get_contents( $this->sSrcDir.$sFilePath );
            if( $this->bMinify ) {
              $sContent = $this->Minify( $sContent );
            }
            $sNewContent = "/**{$sFilePath}**/";
            $sNewContent .= $sContent;
            $sNewContent .= "/**/{$sFilePath}**/\n";
            $sFileContent = preg_replace( $sRegex , $sNewContent , $sFileContent );
          }
        }
        file_put_contents($this->sSaveFile, $sFileContent );
      }
      else if( !$bPartialCache ) {
        foreach( $this->aFiles[$sSaveFile] as $sFilePath ) {
          if( file_exists( $this->sSrcDir.$sFilePath ) ) {
            $sContent = file_get_contents( $this->sSrcDir.$sFilePath );
            if( $this->bMinify ) {
              $sContent = $this->Minify( $sContent );
            }
            $sFileContent .= "/**{$sFilePath}**/";
            $sFileContent .= $sContent;
            $sFileContent .= "/**/{$sFilePath}**/\n";
          }
        }
        file_put_contents($this->sSaveFile, $sFileContent );
      }
      else {
      }
    }
    if( $sAction == 'render' ) {
      $this->Render();
    }
    else if( $sAction == 'build' ) {
      copy( $this->sSaveFile , $this->sBuildFile );
    }
  }

  function ContentType() {
    if( $this->sType == 'js' ) {
      header('Content-Type: text/javascript');
    }
    else if( $this->sType == 'css' ) {
      header('Content-Type: text/css');
    }
  }

  /**
   * Renders the file to the client
   * @since 1.0
   */
  protected function Render() {
    $this->ContentType();
    if( file_exists( $this->sSaveFile ) ) {
      $this->Headers();
      echo file_get_contents( $this->sSaveFile );
    }
    exit();
  }

  /**
   * Checks to see if the file in question has new content and if so it
   * does it sends a status 200 if it is the same it sends 304 and doesnt
   * send any data.
   * @since 1.0
   */
  protected function Headers() {
    $b304 = false;
    $sETag = '"'.md5($this->iLatestTimestamp).'"';

    // Apache Specific Functionality (As it just works)
    if( function_exists( 'apache_request_headers' ) ) {
      $aHeaders = apache_request_headers();
      if (
      !$this->bDebug &&
      isset( $aHeaders['If-Modified-Since'] ) &&
      ( strtotime( $aHeaders['If-Modified-Since'] ) > $this->iLatestTimestamp )
      ) {
        $b304 = true;
      }
    }
    // Fallback option for cache detection
    else {
      if (
      !$this->bDebug &&
      isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) &&
      ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) > $this->iLatestTimestamp )
      ) {
        $b304 = true;
      }
      // We check if the ETAG we are setting is the same
      else if (
      !$this->bDebug && isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) &&
      $_SERVER['HTTP_IF_NONE_MATCH'] == $sETag &&
      ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) > $this->iLatestTimestamp )
      ) {
        $b304 = true;
      }
    }

    /**
     * Choose between a 304 and a 200 error
     */
    if( $b304 ) {
      header( 'Last-Modified: '.gmdate('D, d M Y H:i:s', $this->iLatestTimestamp ).' GMT', true, 304 );
      header( "Expires: ".gmdate('D, d M Y H:i:s' , $this->iLatestTimestamp+365*24*60*60 )." GMT"  );
      exit();
    }
    else {
      header( 'Last-Modified: '.gmdate('D, d M Y H:i:s' ) . ' GMT', true, 200);
      header( "Expires: ".gmdate('D, d M Y H:i:s' , time()+365*24*60*60 )." GMT"  );
      header( 'Cache-Control: public' );
      header( 'ETag: '.$sETag );
      	
      /**
       * This makes things a tad slower on the processing side however
       * because it is only run when its a status 200, which means
       * something has changed or the user downloads it for the first
       * time, then its acceptable given the size benefits which makes
       * it faster for the user to download the file.
       */
      $sErrors = ob_get_clean();
      if( $this->bGzip && $sErrors == '' ) {
        ob_start('ob_gzhandler');
      }
    }
  }

  /**
   * Minifies the content to compress it down in filesize.
   * @param string  $sContent The content to be minified
   * @return string minified string
   * @since 1.0
   */
  protected function Minify( $sContent ) {
    if( $this->sType == 'js' ) {
      $sContent = JSMin::minify( $sContent );
    }
    else if( $this->sType == 'css' ) {
      $sContent = str_replace( "\r\n" , '' , $sContent);  // NO NEW LINES
      $sContent = str_replace( "\n"   , '' , $sContent);  // NO NEW LINES
      $sContent = str_replace( "\r"   , '' , $sContent);  // NO NEW LINES
      $sContent = str_replace( "\t"   , ' ' , $sContent); // Tabs to spaces
      $sContent = preg_replace( "/[\t ]*{/"   , '{ ' , $sContent); // left brace with spaces after it to left brace with a space.
      $sContent = preg_replace( "/{[ ]*/"   , '{' , $sContent); // left brace with spaces after it to left brace with a space.
      $sContent = preg_replace( "/}[ ]*/"   , '} ' , $sContent); // right brace with spaces after it to left brace with a space.
      $sContent = preg_replace( "/;[ ]*/"   , ';' , $sContent); // semi colon with spaces after it to semi colon.
      $sContent = preg_replace( "/:[ ]*/"   , ':' , $sContent); // colon with spaces after it to colon.
      $sContent = preg_replace( "/\/\*.*\*\//Umsi"   , '' , $sContent); // remove comments
    }
    return $sContent;
  }

  /**
   * Add a file to the files to be compiled in array. These must be loaded
   * in order to allow the script to know what order to load them.
   *
   * @param string $sSaveFile   The compiled filename to use as a unique key for the compiling process
   * @param string $sFilePath   The path to the file to be compiled into one file.
   * @since 1.0
   */
  public function AddFile( $sSaveFile , $sFilePath ) {
    $this->aFiles[$sSaveFile][] = $sFilePath;
  }
  /**
   * Adds a source file to the locked filelist which which specifies if the 
   * file will be checked if it is needed to be read
   * @param string $sLockedFile
   * @since 1.5
   */
  public function LockFile ( $sLockedFile ) {
    $this->aFileLocks[] = $sLockedFile;
  }
}